iT邦幫忙

2025 iThome 鐵人賽

DAY 8
0

今天要做什麼?

昨天我們學會了測試替身,解決了外部依賴的測試問題。今天面對一個新的挑戰:「如何測試程式在出錯時的行為?」

想像一個場景:你的應用需要處理各種錯誤情況:

  • 函數接收到無效參數
  • 使用者輸入無效資料
  • 陣列索引超出範圍

很多開發者只測試「快樂路徑」(Happy Path),但真實世界充滿了意外。今天我們要學習如何徹底測試例外處理。

學習目標 🎯

今天結束後,你將學會:

  • 掌握 pytest 的 raises() 和例外斷言
  • 學會測試同步和異步錯誤
  • 理解錯誤測試的最佳實踐

為什麼要測試例外處理? 🤔

# 問題:只考慮成功情況的程式碼
class UserService:
    def get_user_profile(self, user_id: int):
        response = requests.get(f'/api/users/{user_id}')
        user = response.json()  # 如果不是 JSON 格式會怎樣?
        
        return {
            'id': user['id'],
            'name': user['name'].upper()  # 如果 name 是 None 會怎樣?
        }

例外處理測試確保:

  1. 系統穩定性:避免應用程式崩潰
  2. 使用者體驗:提供有意義的錯誤訊息
  3. 除錯效率:快速定位問題根源

pytest 中的例外測試 ⚙️

測試同步錯誤

建立 src/day08/validator.py

class ValidationError(Exception):
    def __init__(self, message: str, field: str = None):
        super().__init__(message)
        self.field = field

def validate_email(email):
    if not email:
        raise ValidationError('Email is required', 'email')
    
    if '@' not in email:
        raise ValidationError('Email must contain @ symbol', 'email')
    
    return True

def divide(a: float, b: float) -> float:
    if b == 0:
        raise ZeroDivisionError('Division by zero is not allowed')
    
    if not isinstance(a, (int, float)) or not isinstance(b, (int, float)):
        raise TypeError('Both arguments must be numbers')
    
    return a / b

建立 tests/day08/test_sync_exceptions.py

import pytest
from src.day08.validator import ValidationError, validate_email, divide

def test_email_validator_throws_error_when_email_is_missing():
    with pytest.raises(ValidationError, match='Email is required'):
        validate_email(None)
    
    with pytest.raises(ValidationError, match='Email is required'):
        validate_email('')

def test_email_validator_throws_error_when_format_is_invalid():
    with pytest.raises(ValidationError, match='Email must contain @ symbol'):
        validate_email('invalid-email')

def test_email_validator_throws_validation_error_with_correct_field():
    with pytest.raises(ValidationError) as exc_info:
        validate_email('invalid')
    
    error = exc_info.value
    assert error.field == 'email'
    assert '@ symbol' in str(error)

def test_email_validator_accepts_valid_emails():
    assert validate_email('user@example.com') is True

def test_divide_function_throws_error_for_division_by_zero():
    with pytest.raises(ZeroDivisionError, match='Division by zero is not allowed'):
        divide(10, 0)

def test_divide_function_throws_type_error_for_non_number_inputs():
    with pytest.raises(TypeError, match='Both arguments must be numbers'):
        divide('10', 2)

def test_divide_function_works_for_valid_inputs():
    assert divide(10, 2) == 5.0

測試異步函數的例外

建立 src/day08/async_service.py

class NetworkError(Exception):
    def __init__(self, message: str, status_code: int = None):
        super().__init__(message)
        self.status_code = status_code

class UserService:
    def __init__(self, http_client):
        self.http_client = http_client

    async def fetch_user(self, user_id: int):
        if not user_id or user_id <= 0:
            raise ValueError('Invalid user ID')

        try:
            response = await self.http_client.get(f'/users/{user_id}')
            
            if not response.ok:
                raise NetworkError(
                    f'Failed to fetch user: {response.status}', 
                    response.status
                )

            user = await response.json()
            
            if not user.get('id') or not user.get('name'):
                raise ValueError('Invalid user data received')

            return user
        except NetworkError:
            raise
        except Exception as error:
            raise NetworkError(f'Network request failed: {error}') from error

建立 tests/day08/test_async_exceptions.py

import pytest
from unittest.mock import AsyncMock, Mock
from src.day08.async_service import UserService, NetworkError

@pytest.fixture
def mock_http_client():
    return Mock(get=AsyncMock())

@pytest.fixture
def user_service(mock_http_client):
    return UserService(mock_http_client)

@pytest.mark.asyncio
async def test_fetch_user_throws_error_for_invalid_user_id(user_service):
    with pytest.raises(ValueError, match='Invalid user ID'):
        await user_service.fetch_user(0)
    
    with pytest.raises(ValueError, match='Invalid user ID'):
        await user_service.fetch_user(-1)

@pytest.mark.asyncio
async def test_fetch_user_throws_network_error_for_http_errors(user_service, mock_http_client):
    mock_response = Mock(ok=False, status=404)
    mock_http_client.get.return_value = mock_response

    with pytest.raises(NetworkError) as exc_info:
        await user_service.fetch_user(1)

    error = exc_info.value
    assert 'Failed to fetch user: 404' in str(error)
    assert error.status_code == 404

@pytest.mark.asyncio
async def test_fetch_user_returns_valid_user_data(user_service, mock_http_client):
    mock_user = {'id': 1, 'name': 'John Doe', 'email': 'john@example.com'}
    mock_response = Mock(ok=True)
    mock_response.json = AsyncMock(return_value=mock_user)
    mock_http_client.get.return_value = mock_response

    result = await user_service.fetch_user(1)
    assert result == mock_user

實戰範例:表單驗證測試 📝

建立 src/day08/form_validator.py

class FormValidationError(Exception):
    def __init__(self, errors: dict):
        super().__init__('Form validation failed')
        self.errors = errors

class UserForm:
    @staticmethod
    def validate(form_data: dict) -> bool:
        errors = {}
        
        if not form_data.get('email'):
            errors['email'] = 'Email is required'
        elif '@' not in form_data['email'] or '.' not in form_data['email']:
            errors['email'] = 'Invalid email format'
        
        if not form_data.get('password'):
            errors['password'] = 'Password is required'
        elif len(form_data['password']) < 8:
            errors['password'] = 'Password must be at least 8 characters'
        
        if form_data.get('password') != form_data.get('confirmPassword'):
            errors['confirmPassword'] = 'Passwords do not match'
        
        if errors:
            raise FormValidationError(errors)
        
        return True

建立 tests/day08/test_form_validator.py

import pytest
from src.day08.form_validator import UserForm, FormValidationError

def test_throws_form_validation_error_for_missing_fields():
    with pytest.raises(FormValidationError) as exc_info:
        UserForm.validate({})
    
    error = exc_info.value
    assert error.errors['email'] == 'Email is required'
    assert error.errors['password'] == 'Password is required'

def test_throws_error_for_invalid_email():
    form_data = {
        'email': 'invalid-email',
        'password': 'ValidPass123',
        'confirmPassword': 'ValidPass123'
    }
    
    with pytest.raises(FormValidationError) as exc_info:
        UserForm.validate(form_data)
    
    assert 'email' in exc_info.value.errors
    assert 'Invalid email format' in exc_info.value.errors['email']

def test_accepts_valid_form_data():
    valid_form_data = {
        'email': 'user@example.com',
        'password': 'ValidPass123',
        'confirmPassword': 'ValidPass123'
    }
    
    assert UserForm.validate(valid_form_data) is True

最佳實踐 ✨

1. 具體的錯誤驗證

# ✅ 好:驗證錯誤類型和內容
with pytest.raises(ValidationError) as exc_info:
    validate_email('invalid')

error = exc_info.value
assert error.field == 'email'
assert '@ symbol' in str(error)

# ❌ 壞:只檢查有錯誤
with pytest.raises(Exception):
    validate_email('invalid')

2. 異步錯誤處理

# ❌ 錯誤:沒有等待異步
def test_handle_async_error():
    with pytest.raises(NetworkError):
        async_function()  # 錯誤!

# ✅ 正確:使用 async/await
@pytest.mark.asyncio
async def test_handle_async_error_correctly():
    with pytest.raises(NetworkError, match='Expected error'):
        await async_function()

完整實作 🛒

完整實作 tests/day08/test_shopping_cart_exceptions.py

import pytest
from unittest.mock import Mock

class ECommerceException(Exception):
    def __init__(self, message: str, code: str = None):
        super().__init__(message)
        self.code = code

class ShoppingCart:
    def __init__(self, inventory):
        self.inventory = inventory
        self.items = []
    
    def add_item(self, product_id, quantity: int = 1):
        if not product_id:
            raise ECommerceException('Product ID is required', 'INVALID_PRODUCT_ID')
        
        if quantity <= 0:
            raise ECommerceException('Quantity must be positive', 'INVALID_QUANTITY')
        
        product = self.inventory.get_product(product_id)
        if not product:
            raise ECommerceException(f'Product not found: {product_id}', 'PRODUCT_NOT_FOUND')
        
        if not self.inventory.is_available(product_id, quantity):
            raise ECommerceException('Insufficient stock', 'INSUFFICIENT_STOCK')
        
        self.items.append({'product_id': product_id, 'quantity': quantity})

def test_shopping_cart_exception_handling():
    mock_inventory = Mock()
    mock_inventory.get_product = lambda id: {'price': 10} if id == 'valid' else None
    mock_inventory.is_available = lambda id, qty: id == 'valid' and qty <= 5
    
    cart = ShoppingCart(mock_inventory)
    
    # 測試無效商品 ID
    with pytest.raises(ECommerceException) as exc_info:
        cart.add_item(None)
    assert exc_info.value.code == 'INVALID_PRODUCT_ID'
    
    # 測試商品不存在
    with pytest.raises(ECommerceException, match='Product not found'):
        cart.add_item('nonexistent')
    
    # 測試庫存不足
    with pytest.raises(ECommerceException, match='Insufficient stock'):
        cart.add_item('valid', 10)
    
    # 測試成功添加
    cart.add_item('valid', 2)
    assert len(cart.items) == 1

今日學習地圖 🗺️

我們現在在測試基礎概念的倒數第三天,已經掌握了大部分核心測試技術:

基礎概念 (1-10)
├── Day 1: 環境設定 ✅
├── Day 2: 基本斷言 ✅
├── Day 3: TDD 循環 ✅
├── Day 4: 測試結構 ✅
├── Day 5: 生命週期 ✅
├── Day 6: 參數化測試 ✅
├── Day 7: 測試替身 ✅
├── Day 8: 例外處理測試 📍 今天
├── Day 9: 測試覆蓋率
└── Day 10: 重構技巧

今天學到什麼?

透過今天的學習,我們掌握了:

  1. pytest 的錯誤斷言raises()、例外類型檢查等方法
  2. 同步和異步錯誤測試:不同類型錯誤的測試技巧
  3. 自定義例外類別:創建有意義的錯誤類型和資訊
  4. 最佳實踐:具體的錯誤驗證和全面的測試覆蓋

例外處理測試讓我們能夠確保程式優雅地處理錯誤,提供有用的錯誤訊息,並維持系統的穩定性。

總結

今天我們學會了例外處理測試,確保程式在各種錯誤情況下都能正確回應。明天我們將學習「測試覆蓋率」,了解如何衡量測試的完整性。

記住:好的例外處理測試不只是檢查是否拋出錯誤,更要驗證錯誤類型、錯誤訊息,以及錯誤發生後的系統狀態!


上一篇
Day 07 - 測試替身基礎 🎭
下一篇
Day 09 - 測試覆蓋率:你的測試真的夠完整嗎? 📊
系列文
Python pytest TDD 實戰:從零開始的測試驅動開發10
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言